#!/bin/bash
#
# A script for running a productive development environment with Docker
# on OS X. See https://github.com/brikis98/docker-osx-dev for more info.

set -e

# Console colors
readonly COLOR_DEBUG='\033[1;36m'
readonly COLOR_INFO='\033[0;32m'
readonly COLOR_WARN='\033[1;33m'
readonly COLOR_ERROR='\033[0;31m'
readonly COLOR_INSTRUCTIONS='\033[0;37m'
readonly COLOR_END='\033[0m'

# Log levels
readonly LOG_LEVEL_DEBUG="DEBUG"
readonly LOG_LEVEL_INFO="INFO"
readonly LOG_LEVEL_WARN="WARN"
readonly LOG_LEVEL_ERROR="ERROR"
readonly LOG_LEVEL_INSTRUCTIONS="INSTRUCTIONS"
readonly LOG_LEVELS="$LOG_LEVEL_DEBUG $LOG_LEVEL_INFO $LOG_LEVEL_WARN $LOG_LEVEL_ERROR $LOG_LEVEL_INSTRUCTIONS"
readonly DEFAULT_LOG_LEVEL="$LOG_LEVEL_INFO"

# Environment variable file constants
readonly BASH_PROFILE="$HOME/.bash_profile"
readonly BASH_RC="$HOME/.bashrc"
readonly ZSH_RC="$HOME/.zshrc"
readonly ENV_FILE_COMMENT="\n# docker-osx-dev\n"

# Script constants
readonly HOSTS_FILE="/etc/hosts"
readonly SYNC_COMMAND="sync"
readonly WATCH_ONLY_COMMAND="watch-only"
readonly SYNC_ONLY_COMMAND="sync-only"
readonly INSTALL_COMMAND="install"
readonly TEST_COMMAND="test_mode"
readonly DEFAULT_COMMAND="$SYNC_COMMAND"
readonly BIN_DIR="/usr/local/bin"

# docker host constants
readonly BOOT2DOCKER_USER="docker"

# docker-compose constants
readonly DEFAULT_COMPOSE_FILE="docker-compose.yml"

# Sync and watch constants
readonly DEFAULT_PATHS_TO_SYNC="."
readonly DEFAULT_EXCLUDES=".git"
readonly DEFAULT_IGNORE_FILE=".dockerignore"
readonly RSYNC_FLAGS="--archive --log-format 'Syncing %n: %i' --delete --omit-dir-times --inplace --whole-file -l"

# docker-osx-dev repo constants
readonly RSYNC_BINARY_URL="https://github.com/brikis98/docker-osx-dev/blob/master/lib/rsync?raw=true"

# Global variables. The should only ever be set by the corresponding
# configure_XXX functions.
PATHS_TO_SYNC=""
EXCLUDES=""
INCLUDES=""
CURRENT_LOG_LEVEL="$DEFAULT_LOG_LEVEL"
DOCKER_HOST_USER=""
DOCKER_HOST_SSH_URL=""
DOCKER_HOST_SSH_KEY=""
DOCKER_HOST_SSH_COMMAND=""


################################################################################
# Utility functions
################################################################################

#
# Dumps a 'stack trace' for failed assertions.
#
function backtrace {
  local readonly max_trace=20
  local frame=0
  while test $frame -lt $max_trace ; do
    frame=$(( $frame + 1 ))
    local bt_file=${BASH_SOURCE[$frame]}
    local bt_function=${FUNCNAME[$frame]}
    local bt_line=${BASH_LINENO[$frame-1]}  # called 'from' this line
    if test -n "${bt_file}${bt_function}" ; then
      log_error "  at ${bt_file}:${bt_line} ${bt_function}()"
    fi
  done
}

#
# Usage: assert_non_empty VAR
#
# Asserts that VAR is not empty and exits with an error code if it is.
#
function assert_non_empty {
  local readonly var="$1"

  if test -z "$var" ; then
    log_error "internal error: unexpected empty-string argument"
    backtrace
    exit 1
  fi
}

#
# Usage: assert_mutually_exclusive ERROR_MESSAGE VAR1 VAR2 ... VARn
#
# Asserts that at most one of VAR1, VAR2, ..., VARn is not empty
#
function assert_mutually_exclusive {
  local readonly error_message=$1
  shift
  local found
  while [[ $# > 0 ]]; do
    local value=$1
    if test -n "$value" ; then
      if test -n "$found" ; then
        log_error "$error_message"
        instructions
        exit 1
      else
        found=true
      fi
    fi
    shift
  done
}

#
# Usage: index_of VALUE ARRAY
#
# Returns the first index where VALUE appears in ARRAY. If ARRAY does not
# contain VALUE, returns -1.
#
# Examples:
#
# arr=("abc" "foo" "def")
# index_of foo "${arr[@]}"
#   Returns: 1
#
# arr=("abc" "def")
# index_of foo "${arr[@]}"
#   Returns -1
#
# index_of foo "abc" "def" "foo"
#   Returns 2
#
function index_of {
  local readonly value="$1"
  shift
  local readonly array=("$@")
  local i=0

  for (( i = 0; i < ${#array[@]}; i++ )); do
    if [ "${array[$i]}" = "${value}" ]; then
      echo $i
      return
    fi
  done

  echo -1
}

#
# Usage: join SEPARATOR ARRAY
#
# Joins the elements of ARRAY with the SEPARATOR character between them.
#
# Examples:
#
# join ", " ("A" "B" "C")
#   Returns: "A, B, C"
#
function join {
  local readonly separator="$1"
  shift
  local readonly values=("$@")

  printf "%s$separator" "${values[@]}" | sed "s/$separator$//"
}

################################################################################
# Logging
################################################################################

#
# Returns the current timestamp formatted for logging
#
function format_timestamp {
  date +"%Y-%m-%d %H:%M:%S"
}

# Helper function to log an INFO message. See the log function for details.
function log_info {
  log "$COLOR_INFO" "$COLOR_END" "$(format_timestamp)" "$LOG_LEVEL_INFO" "$@"
}

# Helper function to log a WARN message. See the log function for details.
function log_warn {
  log "$COLOR_WARN" "$COLOR_END" "$(format_timestamp)" "$LOG_LEVEL_WARN" "$@"
}

# Helper function to log a DEBUG message. See the log function for details.
function log_debug {
  log "$COLOR_DEBUG" "$COLOR_END" "$(format_timestamp)" "$LOG_LEVEL_DEBUG" "$@"
}

# Helper function to log an ERROR message. See the log function for details.
function log_error {
  log "$COLOR_ERROR" "$COLOR_END" "$(format_timestamp)" "$LOG_LEVEL_ERROR" "$@"
}

# Helper function to log an INSTRUCTIONS message. See the log function for details.
function log_instructions {
  log "$COLOR_INSTRUCTIONS" "$COLOR_END" "$(format_timestamp)" "$LOG_LEVEL_INSTRUCTIONS" "$@"
}

#
# Usage: log COLOR COLOR_END TIMESTAMP LEVEL [MESSAGE ...]
#
# Logs MESSAGE, at time TIMESTAMP, surrounded by COLOR and COLOR_END, to stdout
# if the log level is at least LEVEL. If no MESSAGE is specified, reads from
# stdin. The log level is determined by the DOCKER_OSX_DEV_LOG_LEVEL environment
# variable.
#
# Examples:
#
# log "\033[0;32m" "\033[0m" "2015-06-03 15:30:33" "INFO" "Hello, World"
#   Prints: "\033[0;32m2015-06-03 15:30:33 [INFO] Hello, World\033[0m" to stdout.
#
# echo "Hello, World" | log "\033[0;32m" "\033[0m" "2015-06-03 15:30:33" "ERROR"
#   Prints: "\033[0;32m2015-06-03 15:30:33 [ERROR] Hello, World\033[0m" to stdout.
#
function log {
  if [[ "$#" -gt 4 ]]; then
    do_log "$@"
  elif [[ "$#" -eq 4 ]]; then
    local message=""
    while read message; do
      do_log "$1" "$2" "$3" "$4" "$message"
    done
  else
    echo "Internal error: invalid number of arguments passed to log function: $@"
    exit 1
  fi
}

#
# Usage: do_log COLOR COLOR_END TIMESTAMP LEVEL MESSAGE ...
#
# Logs MESSAGE, at time TIMESTAMP, surrounded by COLOR and COLOR_END, to stdout
# if the log level is at least LEVEL. The log level is determined by the
# DOCKER_OSX_DEV_LOG_LEVEL environment variable.
#
# Examples:
#
# do_log "\033[0;32m" "\033[0m" "INFO" "Hello, World"
#   Prints: "\033[0;32m[INFO] Hello, World\033[0m" to stdout.
#
function do_log {
  local readonly color="$1"
  shift
  local readonly color_end="$1"
  shift
  local readonly timestamp="$1"
  shift
  local readonly log_level="$1"
  shift
  local readonly message="$@"

  local readonly log_level_index=$(index_of "$log_level" $LOG_LEVELS)
  local readonly current_log_level_index=$(index_of "$CURRENT_LOG_LEVEL" $LOG_LEVELS)

  if [[ "$log_level_index" -ge "$current_log_level_index" ]]; then
    echo -e "${color}${timestamp} [${log_level}] ${message}${color_end}"
  fi
}

#
# Usage: assert_valid_log_level LEVEL
#
# Asserts that LEVEL is a valid log level--that is, it's one of the values in
# LOG_LEVELS.
#
function assert_valid_log_level {
  local readonly level="$1"
  local readonly index=$(index_of "$level" $LOG_LEVELS)

  if [[ "$index" -lt 0 ]]; then
    echo "Invalid log level specified: $level"
    instructions
    exit 1
  fi
}

#
# Usage: configure_log_level LEVEL
#
# Set the logging level to LEVEL. LEVEL must be one of the values in LOG_LEVELS.
#
function configure_log_level {
  local readonly level="$1"
  assert_valid_log_level "$level"
  CURRENT_LOG_LEVEL="$level"
}

################################################################################
# Boot2Docker manipulation
################################################################################

#
# Configures the Boot2Docker SSH key by looking into the Boot2Docker config
#
function configure_boot2docker {
  test -n "$DOCKER_HOST_NAME" || DOCKER_HOST_NAME="dockerhost"
  DOCKER_HOST_SSH_KEY=$(boot2docker cfg | grep "^SSHKey = " | sed -e 's/^SSHKey = "\(.*\)"/\1/')
  DOCKER_HOST_USER="$BOOT2DOCKER_USER"
  DOCKER_HOST_SSH_URL="$BOOT2DOCKER_USER@$DOCKER_HOST_NAME"
  DOCKER_HOST_SSH_COMMAND="boot2docker ssh"
}

#
# Usage: find_vboxsf_mounted_folders
#
# Returns mounted volumes with vboxsf-type in boot2docker instance.
#
function find_vboxsf_mounted_folders {
  $DOCKER_HOST_SSH_COMMAND mount | grep 'type vboxsf' | awk '{print $3}'
}

#
# Usage: umount_vboxsf_mounted_folder SHARED_FOLDERS
#
# Remove the VirtualBox shared folder from the boot2docker VM.
# SHARED_FOLDERS should be the output of the
# find_vboxsf_mounted_folders function.
#
function umount_vboxsf_mounted_folder {
  local readonly vbox_shared_folders="$1"
  local vbox_shared_folder=''
  while read -r vbox_shared_folder; do
    log_info "Removing shared folder: $vbox_shared_folder"
    $DOCKER_HOST_SSH_COMMAND sudo umount "$vbox_shared_folder"
  done <<< "$vbox_shared_folders"
}

#
# Usage: check_for_shared_folders REMOVE_SHARED_FOLDERS
#
# Checks if the docker host has any VirtualBox shared folders. If so, prompt
# the user if they would like to remove them, as they will void any benefits
# from using rsync. If AUTOREMOVE is true, the folders will be removed without
# prompting the user.
#
function check_for_shared_folders {
  local readonly remove_shared_folders="$1"
  local readonly vbox_shared_folders=$(find_vboxsf_mounted_folders)

  if [[ ! -z "$vbox_shared_folders" ]]; then
    log_error "Found VirtualBox shared folders on your Boot2Docker VM. These may void any performance benefits from using docker-osx-dev:\n$vbox_shared_folders"

    if [[ "$remove_shared_folders" = true ]]; then
        log_info "Autoremoving shared folders."
        umount_vboxsf_mounted_folder "$vbox_shared_folders"
    else
      log_instructions "Would you like this script to remove them?"
      local choice=""
      select choice in "yes" "no"; do
        case $REPLY in
          y|Y|yes|Yes )
            umount_vboxsf_mounted_folder "$vbox_shared_folders"
            break
            ;;
          n|N|no|No )
            log_instructions "Please remove the VirtualBox shares yourself and re-run this script. Exiting."
            exit 1
            ;;
        esac
      done
    fi
    fi
}

#
# Returns true iff the Boot2Docker VM is initialized
#
function is_boot2docker_initialized {
  boot2docker status >/dev/null 2>&1
}

#
# Returns true iff the Boot2Docker VM is running
#
function is_boot2docker_running {
  local readonly status=$(boot2docker status 2>&1)
  test "$status" = "running"
}

#
# Usage: init_boot2docker REMOVE_SHARED_FOLDERS
#
# Initializes and starts up the Boot2Docker VM.
#
function init_boot2docker {
  local readonly remove_shared_folders=$1

  if ! is_boot2docker_initialized; then
    log_info "Initializing Boot2Docker VM"
    boot2docker init
  fi

  if ! is_boot2docker_running; then
    log_info "Starting Boot2Docker VM"
    boot2docker start --vbox-share=disable
  fi

  configure_boot2docker
  check_for_shared_folders "$remove_shared_folders"
}

################################################################################
# docker-machine manipulation
################################################################################


#
# Inspect specific configuration about the docker-machine vm
#
function inspect_docker_machine {
  local result=$(docker-machine inspect --format="$*" "$DOCKER_MACHINE_NAME" 2>&1)
  if test "${result}" != "<no value>"; then
    echo ${result}
  else
    return -1
  fi
}

#
# Configures variables based on the output of `docker-machine inspect $DOCKER_MACHINE_NAME`
#
function configure_docker_machine {
  DOCKER_HOST_NAME="$DOCKER_MACHINE_NAME"
  # Support < 0.4.1 and >= 0.5.1
  DOCKER_HOST_USER=$(inspect_docker_machine "{{.Driver.SSHUser}}" || inspect_docker_machine "{{.Driver.Driver.SSHUser}}")
  DOCKER_HOST_IP=$(docker-machine ip "$DOCKER_MACHINE_NAME" || inspect_docker_machine "{{.Driver.IPAddress}}" || inspect_docker_machine "{{.Driver.Driver.IPAddress}}")
  # Support both version 0.4.1 (and earlier) and 0.5.0 (and later)
  DOCKER_MACHINE_STORE_PATH=$(inspect_docker_machine "{{.StorePath}}" || inspect_docker_machine "{{.HostOptions.AuthOptions.StorePath}}")
  DOCKER_MACHINE_DRIVER_NAME=$(inspect_docker_machine "{{.DriverName}}")

  DOCKER_HOST_SSH_URL="$DOCKER_HOST_USER@$DOCKER_HOST_IP"
  # 0.5.0 returns StorePath as a root directory /machine
  DOCKER_HOST_SSH_KEY="$DOCKER_MACHINE_STORE_PATH/machines/$DOCKER_MACHINE_NAME/id_rsa"

  if [[ ! -f $DOCKER_HOST_SSH_KEY ]]; then
    # 0.4.1 returns StorePath as /machines/dev
    DOCKER_HOST_SSH_KEY="$DOCKER_MACHINE_STORE_PATH/id_rsa"
  fi

  if [[ $CURRENT_LOG_LEVEL == "DEBUG" ]]; then
    DOCKER_HOST_SSH_COMMAND="docker-machine -D ssh $DOCKER_MACHINE_NAME"
  else
    DOCKER_HOST_SSH_COMMAND="docker-machine ssh $DOCKER_MACHINE_NAME"
  fi
}

#
# Usage: init_docker_machine REMOVE_SHARED_FOLDERS
#
# Initializes and starts up the docker-machine VM.
#
function init_docker_machine {
  local readonly remove_shared_folders=$1

  if ! is_docker_machine_running; then
    log_info "Initializing docker machine $DOCKER_MACHINE_NAME"
    docker-machine start "$DOCKER_MACHINE_NAME"
  fi
  eval "$(docker-machine env --shell bash $DOCKER_MACHINE_NAME)"
  configure_docker_machine
  if [[ $DOCKER_MACHINE_DRIVER_NAME == "virtualbox" ]]; then
    check_for_shared_folders "$remove_shared_folders"
  fi
}

#
# Returns true iff the docker-machine VM is running
#
function is_docker_machine_running {
  log_info "Testing if docker machine is running"
  local readonly status=$(docker-machine status $DOCKER_MACHINE_NAME 2>&1)
  test "$status" = "Running"
}

################################################################################
# Generic docker host manipulation
################################################################################

#
# Usage: init_docker_host REMOVE_SHARED_FOLDERS
#
# Initializes a docker host
# Initializes boot2docker, unless DOCKER_MACHINE_NAME is defined
#
function init_docker_host {
  local readonly remove_shared_folders=$1
  if [[ -n "$DOCKER_MACHINE_NAME" ]]; then
    init_docker_machine "$remove_shared_folders"
  else
    init_boot2docker "$remove_shared_folders"
  fi
}

#
# Installs rsync on the Boot2Docker VM, unless it's already installed.
# If the main repository is down, rsync will be installed from a mirror
# If the mirror is down, download rsync binary and place in Boot2Docker VM
#
function install_rsync_on_docker_host {
  log_info "Installing rsync in the Docker Host image"

  if ! $DOCKER_HOST_SSH_COMMAND "if ! type rsync > /dev/null 2>&1; then tce-load -wi rsync; fi"; then

    # For some reason, the tce-load command often exits with an error code, even
    # if rsync installed successfully. Therefore, re-run just the type command
    # to see if it actually worked

    if ! $DOCKER_HOST_SSH_COMMAND "type rsync > /dev/null 2>&1" ; then
      log_info "Failed to install rsync using tce-load, falling back to install rsync from pre-built binary in docker-osx-dev GitHub repo"
      $DOCKER_HOST_SSH_COMMAND "sudo mkdir -p $BIN_DIR && sudo wget -O $BIN_DIR/rsync $RSYNC_BINARY_URL && sudo chmod +x $BIN_DIR/rsync"
    fi
  fi
}

################################################################################
# Environment setup
################################################################################

#
# Usage: env_is_defined VAR
#
# Checks if a new SHELL has VAR defined in its environment.
# Returns 0 when VAR is defined for new shells, 1 otherwise.
#
function env_is_defined {
  local readonly var="$1"
  assert_non_empty "$var"

  local readonly setting=$(env | grep "^${var}=")
  test -n "$setting"
}

#
# Usage: get_env_file
#
# Tries to find and return the proper environment file for the current user.
#
# Examples:
#
# get_env_file
#   Returns: ~/.bash_profile
#
function get_env_file {
  if [[ -f "$BASH_RC" ]]; then
    echo "$BASH_RC"
  elif [[ -f "$ZSH_RC" ]]; then
    echo "$ZSH_RC"
  else
    echo "$BASH_PROFILE"
  fi
}

#
# Adds environment variables necessary for running Boot2Docker
#
function add_environment_variables {
  if [ -z "$DOCKER_MACHINE_NAME" ]; then
    local readonly env_file=$(get_env_file)
    local readonly boot2docker_exports=$(boot2docker shellinit 2>/dev/null)
    local readonly exports_to_add_to_env_file=$(determine_boot2docker_exports_for_env_file "$boot2docker_exports")

    if [[ ! -z "$exports_to_add_to_env_file" ]]; then
      log_info "Adding new environment variables to $env_file: $exports_to_add_to_env_file"
      echo -e "$exports_to_add_to_env_file" >> "$env_file"
      log_instructions "To pick up important new environment variables in the current shell, run:\n\tsource $env_file"
    else
      log_warn "All Boot2Docker environment variables already defined, will not overwrite"
    fi
  fi
}

#
# Usage: determine_boot2docker_exports_for_env_file BOOT2DOCKER_SHELLINIT_EXPORTS
#
# Parses BOOT2DOCKER_SHELLINIT_EXPORTS, which should be the output of the
# boot2docker shelinit command, and returns a string of the exports that are
# not already in the current environment.
#
function determine_boot2docker_exports_for_env_file {
  local readonly boot2docker_exports="$1"

  local exports_to_add_to_env_file=()
  local export_line=""

  while read -r export_line; do
    if [[ ! -z "$export_line" ]]; then
      local readonly var_name=$(echo "$export_line" | sed -ne 's/export \(.*\)=.*/\1/p')

      if [[ -z "$var_name" ]]; then
        log_error "Unexpected entry from boot2docker shellinit: $export_line"
        exit 1
      elif ! env_is_defined "$var_name"; then
        exports_to_add_to_env_file+=("$export_line")
      fi
    fi
  done <<< "$boot2docker_exports"

  if [[ "${#exports_to_add_to_env_file[@]}" -gt 0 ]]; then
    local exports_as_string=$(join "\n" "${exports_to_add_to_env_file[@]}")
    echo -e "$ENV_FILE_COMMENT$exports_as_string"
  else
    echo ""
  fi
}

#
# Adds Docker entries to /etc/hosts
#
function add_docker_host {
  if grep -q "^[^#]*$DOCKER_HOST_NAME" "$HOSTS_FILE" ; then
    log_warn "$HOSTS_FILE already contains $DOCKER_HOST_NAME, will not overwrite"
  else
    DOCKER_HOST_IP=${DOCKER_HOST_IP-$(boot2docker ip)}
    local readonly host_entry="\n$DOCKER_HOST_IP $DOCKER_HOST_NAME"

    log_info "Adding $DOCKER_HOST_NAME entry to $HOSTS_FILE so you can use http://$DOCKER_HOST_NAME URLs for testing"
    log_instructions "Modifying $HOSTS_FILE requires sudo privileges, please enter your password."
    sudo -k sh -c "echo \"$host_entry\" >> $HOSTS_FILE"
  fi
}

################################################################################
# Installation
################################################################################

#
# Checks that this script can be run on the current machine and exits with an
# error code if any of the requirements are missing.
#
function check_prerequisites {
  local readonly os=$(uname)

  if [[ ! "$os" = "Darwin" ]]; then
    log_error "This script should only be run on OS X"
    exit 1
  fi
}

#
# Update Brew formulae and HomeBrew
#
# Non-breaking. Falls through with warning if fails.
#
function brew_update {
  log_info "Updating HomeBrew"
  if ! type brew > /dev/null 2>&1 ; then
    log_warn "HomeBrew not found, will not attempt to update it"
  else
    brew update
  fi
}

#
# Usage: brew_install PACKAGE_NAME READABLE_NAME EXISTENCE_TEST USE_CASK
#
# Checks if PACKAGE_NAME is already installed by using brew as well as by
# running `eval EXISTENCE_TEST` and if it can't find it, uses brew to
# install PACKAGE_NAME. If USE_CASK is set to true, uses brew cask
# instead.
#
# Examples:
#
# brew_install virtualbox VirtualBox 'type vboxwebsrv' true
#   Result: checks if brew cask already has virtualbox installed or
#   `type vboxwebsrv` succeeds, and if not, uses brew cask to install it.
#
function brew_install {
  local readonly package_name="$1"
  local readonly readable_name="$2"
  local readonly existence_test="$3"
  local readonly use_cask="$4"

  if ! eval "$existence_test" > /dev/null 2>&1 ; then
    check_homebrew $readable_name

    local brew_command="brew"
    if [[ "$use_cask" = true ]]; then
      brew_command="brew cask"
    fi

    if eval "$brew_command list $package_name" > /dev/null 2>&1 ; then
      log_warn "$readable_name is already installed by HomeBrew, skipping"
    else
      log_info "Installing $readable_name"
      eval "$brew_command install $package_name"

      # It seems that brew install can fail to install a dependency, but exit
      # without an error code, so the set -e flag does not help us. Example:
      # https://github.com/brikis98/docker-osx-dev/issues/124
      # Therefore, explicitly check that the dependency was installed before
      # proceeding.
      if ! eval "$existence_test" > /dev/null 2>&1; then
        log_error "Failed to install dependency $readable_name. Check the log output above for reasons."
        exit 1
      fi
    fi
  else
    log_warn "Test \"$existence_test\" passed, assuming $readable_name is already installed and skipping"
  fi
}

#
# Checks if HomeBrew is installed.
# Called only in case of a missing dependency.
#
function check_homebrew {
  local readonly missing_package="$1"
  if ! type brew > /dev/null 2>&1 ; then
    log_error "HomeBrew is required to install $missing_package, but it's not installed. Aborting."
    exit 1
  fi
}

#
# Installs all the dependencies for docker-osx-dev.
#
function install_dependencies {
  brew_update

  log_info "Setting up Cask"
  if ! brew tap caskroom/cask; then
    log_error "Failed to set up Cask. Check the log output above for reasons."
    exit 1
  fi

  brew_install "caskroom/cask/brew-cask" "Cask" "brew cask list" false
  brew_install "boot2docker" "Boot2Docker" "type boot2docker" false
  brew_install "docker-compose" "Docker Compose" "type docker-compose" false
  brew_install "docker-machine" "Docker Machine" "type docker-machine" false
  brew_install "fswatch" "fswatch" "type fswatch" false
  brew_install "coreutils" "GNU core utilities" "type greadlink" false
}

#
# Prints instructions on what the user should do next
#
function print_next_steps {
  log_info "docker-osx-dev setup has completed successfully."
  log_instructions "You can now start file syncing using the docker-osx-dev" \
    "script and run Docker containers using docker run. Example:\n\t" \
    "> docker-osx-dev\n\t" \
    '> docker run -v $(pwd):/src some-docker-container'
}

################################################################################
# File syncing
################################################################################

#
# Usage: find_path_to_sync_parent PATH
#
# Finds the parent folder of PATH from the PATHS_TO_SYNC global variable. When
# using rsync, we want to sync the exact folders the user specified when
# running the docker-osx-dev script. However, when we we use fswatch, it gives
# us the path of files that changed, which may be deeply nested inside one of
# the folders we're supposed to keep in sync. Therefore, this function lets us
# transform one of these nested paths back to one of the top level rsync paths.
#
function find_path_to_sync_parent {
  local readonly path="$1"
  local readonly normalized_path=$(greadlink -m $(eval echo '$path'))
  local readonly paths_to_sync=($PATHS_TO_SYNC)

  local path_to_sync=""
  for path_to_sync in "${paths_to_sync[@]}"; do
    if [[ "$normalized_path" == $path_to_sync || "$normalized_path" == $path_to_sync\/* ]]; then
      echo "$path_to_sync"
      return
    fi
  done
}

#
# Usage: rsync PATH
#
# Uses rsync to sync PATH to the same PATH on the Boot2Docker VM.
#
# Examples:
#
# rsync /foo
#   Result: the contents of /foo are rsync'ed to /foo on the Boot2Docker VM
#
function do_rsync {
  local readonly path="$1"
  local readonly path_to_sync=$(find_path_to_sync_parent "$path")

  if [[ -z "$path_to_sync" ]]; then
    log_error "Internal error: can't sync '$path' because it doesn't seem to be part of any paths configured for syncing: $PATHS_TO_SYNC"
  else
    local readonly parent_folder=$(dirname "$path_to_sync")

    local excludes=()
    read -a excludes <<< "$EXCLUDES"
    local readonly exclude_flags="${excludes[@]/#/--exclude }"

    local includes=()
    read -a includes <<< "$INCLUDES"
    local readonly include_flags="${includes[@]/#/--include }"

    local readonly rsh_flag="--rsh=\"ssh -i $DOCKER_HOST_SSH_KEY -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\""

    local readonly rsync_cmd="rsync $RSYNC_FLAGS $include_flags $exclude_flags $rsh_flag $path_to_sync $DOCKER_HOST_SSH_URL:$parent_folder 2>&1 | grep -v \"^Warning: Permanently added\""
    log_debug "$rsync_cmd"

    eval "$rsync_cmd" 2>&1 | log_info
  fi
}

#
# Usage: do_sync [PATHS ...]
#
# Uses rsync to sync PATHS to the Boot2Docker VM. If one of the values in PATHS
# is not valid (e.g. doesn't exist), it will be ignored.
#
# Examples:
#
# rsync /foo /bar
#   Result: /foo and /bar are rsync'ed to the Boot2DockerVM
#
function do_sync {
  local readonly paths_to_sync=("$@")
  local path=""

  for path in "${paths_to_sync[@]}"; do
    do_rsync "$path"
  done
}

#
# Usage: create_sync_directories [PATHS ...]
#
# Sets up all necessary parent directories and permissions for PATHS in the
# Boot2Docker VM.
function create_sync_directories {
  local readonly paths_to_sync=("$@")

  local dirs_to_create=()
  local path=""

  for path in "${paths_to_sync[@]}"; do
    local readonly parent_dir=$(dirname "$path")
    if [[ "$parent_dir" != "/" ]]; then
      dirs_to_create+=("$parent_dir")
    fi
  done

  local readonly dir_string=$(join " " "${dirs_to_create[@]}")
  local readonly mkdir_string="sudo mkdir -p $dir_string"
  local readonly chown_string="sudo chown -R $DOCKER_HOST_USER $dir_string"
  local readonly ssh_cmd="$mkdir_string && $chown_string"

  log_debug "Creating parent directories in Docker VM: $ssh_cmd"
  $DOCKER_HOST_SSH_COMMAND "$ssh_cmd"
}

#
# Usage: tar_sync [PATHS ...]
#
# Use tar to set up the initial sync of PATHS in the Boot2Docker VM, which
# is faster than letting rsync do it.
function tar_sync {
  local readonly paths_to_sync=("$@")

  local path=""
  local parent_dir=""
  local base_name=""
  local readonly excludes=($EXCLUDES)
  local readonly exclude_flags=${excludes[@]/#/--exclude }
  local readonly includes=($INCLUDES)
  local readonly include_flags=${includes[@]/#/--include }

  for path in "${paths_to_sync[@]}"; do
    parent_dir=$(dirname "$path")
    base_name=$(basename "$path")
    if $DOCKER_HOST_SSH_COMMAND "test -e '$parent_dir/$base_name'" > /dev/null 2>&1; then
      log_debug "skipped tar for $parent_dir/$base_name"
    else
      log_info "Initial sync using tar for $parent_dir/$base_name"
      tar -cC "$parent_dir" $include_flags $exclude_flags "$base_name" | $DOCKER_HOST_SSH_COMMAND "tar -xC '$parent_dir'"
    fi
  done
}

#
# Usage: initial_sync
#
# Perform the initial sync of PATHS_TO_SYNC to the Boot2Docker VM, including
# setting up all necessary parent directories and permissions.
#
function initial_sync {
  local readonly paths_to_sync=($PATHS_TO_SYNC)
  log_info "Performing initial sync of paths: ${paths_to_sync[@]}"

  create_sync_directories "${paths_to_sync[@]}"

  tar_sync "${paths_to_sync[@]}"

  log_debug "Starting sync paths: $paths_to_sync"
  do_sync "${paths_to_sync[@]}"
  log_info "Initial sync done"
}

#
# Usage: watch
#
# Watches the paths in the global variable PATHS_TO_SYNC for changes and rsyncs
# any files that changed.
#
function watch {
  log_info "Watching: $PATHS_TO_SYNC"

  local readonly fswatch_cmd="fswatch -0 $PATHS_TO_SYNC"
  log_debug "$fswatch_cmd"

  local file=""
  eval "$fswatch_cmd" | while read -d "" file
  do
    do_sync "$file"
  done
}

################################################################################
# User input and command line args
################################################################################

#
# Usage: instructions
#
# Prints the usage instructions for this script to stdout.
#
function instructions {
  echo -e
  echo -e "Usage: docker-osx-dev [COMMAND] [OPTIONS]"
  echo -e
  echo -e "Commands:"
  echo -e "  $SYNC_COMMAND\t\tStart file syncing. This is the default if no COMMAND is specified."
  echo -e "  $WATCH_ONLY_COMMAND\tWatch the file system for changes, without syncing first."
  echo -e "  $SYNC_ONLY_COMMAND\tSync the file system and exit, without watching afterwords."
  echo -e "  $INSTALL_COMMAND\tInstall docker-osx-dev and all of its dependencies."
  echo -e
  echo -e "Options:"
  echo -e "  -m, --machine-name name\t\tWhen suplied syncs with the given docker machine host"
  echo -e "  -s, --sync-path PATH\t\t\tSync PATH to the Boot2Docker VM. No wildcards allowed. May be specified multiple times. Default: $DEFAULT_PATHS_TO_SYNC"
  echo -e "  -e, --exclude-path PATH\t\tExclude PATH while syncing. Behaves identically to rsync's --exclude parameter. May be specified multiple times. Default: $DEFAULT_EXCLUDES"
  echo -e "  -c, --compose-file COMPOSE_FILE\tRead in this docker-compose file and sync any volumes specified in it. Default: $DEFAULT_COMPOSE_FILE"
  echo -e "  -i, --ignore-file IGNORE_FILE\t\tRead in this ignore file and exclude any paths within it while syncing (see --exclude). Default: $DEFAULT_IGNORE_FILE"
  echo -e "      --only-dependencies\t\tDuring install, only install the homebrew dependencies. Useful if homebrew is needs to be run as a different user."
  echo -e "      --skip-dependencies\t\tDuring install, don't install the homebrew dependencies. Useful if homebrew is needs to be run as a different user."
  echo -e "  -r, --remove-shared-folders \t\tAutomatically remove Virtualbox shared folders. Shared folders may void any performance benefits from using docker-osx-dev"
  echo -e "  -l, --log-level LOG_LEVEL\t\tSpecify the logging level. One of: $LOG_LEVELS. Default: ${DEFAULT_LOG_LEVEL}"
  echo -e "  -h, --help\t\t\t\tPrint this help text and exit."
  echo -e
  echo -e "Overview:"
  echo -e
  echo -e "docker-osx-dev is a script you can use to sync folders to the Boot2Docker (or docker-machine) VM using rsync."
  echo -e "It's an alternative to using VirtualBox shared folders, which are agonizingly slow and break file watchers."
  echo -e "For more info, see: https://github.com/brikis98/docker-osx-dev"
  echo -e
  echo -e "Example workflow:"
  echo -e "  > docker-osx-dev -s /host-folder"
  echo -e "  > docker run -v /host-folder:/guest-folder some-docker-image"
  echo -e
  echo -e "  After you run the commands above, /host-folder on OS X will be kept in sync with /guest-folder in some-docker-image."
  echo -e
}

#
# Usage: load_paths_from_docker_compose DOCKER_COMPOSE_FILE
#
# Parses out all volumes: entries from the docker-compose file
# DOCKER_COMPOSE_FILE. This is a very hacky function that just uses regex
# instead of a proper yaml parser. If it proves to be fragile, it will need to
# be replaced.
#
function load_paths_from_docker_compose {
  local readonly yaml_file_path="$1"
  local paths=()

  local docker_compose_param_yaml_file=""
  if [[ ! -z "${yaml_file_path}" ]]; then
    docker_compose_param_yaml_file="-f ${yaml_file_path}"
  fi

  # Parse docker-compose YAML configuration
  # Here is an example of output sent by docker-compose config:
  # networks: {}
  # services:
  #   db:
  #     image: baz/blah
  #     network_mode: bridge
  #   web:
  #     image: foo/bar
  #     links:
  #     - db
  #     network_mode: bridge
  #     ports:
  #     - 3000:3000
  #     volumes:
  #     - /host:/guest:rw
  # version: '2.0'
  # volumes: {}
  local in_volumes_block=false
  local line=""
  while read line; do
    if $in_volumes_block; then
      if [[ "${line:0:2}" = "- " ]]; then
        local readonly path=$(echo $line | sed -ne "s/- \(\/[^:]*\):.*$/\1/p")
        if [ ! -z "$path" ]; then
          paths+=("$path")
        fi
      else
        in_volumes_block=false
      fi
    else
      if [[ "$line" = "volumes:" ]]; then
        in_volumes_block=true
      fi
    fi
  done < <(docker-compose ${docker_compose_param_yaml_file} config 2> /dev/null)
  # Do not use a pipe to prevent a subshell

  echo "${paths[@]}"
}

#
# Usage: load_exclude_paths IGNORE_FILE
#
# Parse the paths from IGNORE_FILE that are of the format used by .gitignore and
# .dockerignore: that is, each line contains a single path, and lines that
# start with a pound sign are treated as comments. Lines that start with a !
# are includes and are also ignored.
#
function load_exclude_paths {
  local readonly ignore_file="$1"
  local paths=()

  if [[ -f "$ignore_file" ]]; then
    local line=""
    while read line; do
      if [[ "${line:0:1}" != "#" ]] && [[ "${line:0:1}" != "!" ]]; then
        paths+=("$line")
      fi
    done < "$ignore_file"
  fi

  echo "${paths[@]}"
}

#
# Usage: load_include_paths IGNORE_FILE
#
# Parse the paths from IGNORE_FILE that are of the format used by .gitignore and
# .dockerignore: that is, each line contains a single path, and only lines
# starting with a ! are kept.
#
function load_include_paths {
  local readonly ignore_file="$1"
  local paths=()

  if [[ -f "$ignore_file" ]]; then
    local line=""
    while read line; do
      if [[ "${line:0:1}" == "!" ]]; then
        paths+=("${line:1}")
      fi
    done < "$ignore_file"
  fi

  echo "${paths[@]}"
}

#
# Usage: configure_paths_to_sync COMPOSE_FILE [PATHS_FROM_CMD_LINE ...]
#
# Set \the paths that should be synced to the Boot2Docker VM. These
# paths will be read from the Docker Compose file COMPOSE_FILE as well as paths
# specified via the command line as PATHS_FROM_CMD_LINE. If no paths are found
# in either place, this function will fall back to DEFAULT_PATHS_TO_SYNC.
#
function configure_paths_to_sync {
  local readonly compose_file="$1"
  shift
  local readonly paths_to_sync_from_cmd_line=("$@")
  local readonly paths_to_sync_from_compose_file=($(load_paths_from_docker_compose "$compose_file"))

  local paths_to_sync=()
  if [[ "${#paths_to_sync_from_cmd_line[@]}" -gt 0 ]]; then
    log_info "Using sync paths from command line args: ${paths_to_sync_from_cmd_line[@]}"
    paths_to_sync+=("${paths_to_sync_from_cmd_line[@]}")
  fi

  if [[ "${#paths_to_sync_from_compose_file}" -gt 0 ]]; then
    log_info "Using sync paths from Docker Compose file at $compose_file: ${paths_to_sync_from_compose_file[@]}"
    paths_to_sync+=("${paths_to_sync_from_compose_file[@]}")
  fi

  if [[ "${#paths_to_sync[@]}" -eq 0 ]]; then
    log_info "Using default sync paths: $DEFAULT_PATHS_TO_SYNC"
    paths_to_sync=($DEFAULT_PATHS_TO_SYNC)
  fi

  local normalized_paths_to_sync=()
  local path=""
  for path in "${paths_to_sync[@]}"; do
    local normalized_path=$(greadlink -m $(eval echo "$path"))
    normalized_paths_to_sync+=("$normalized_path")
  done

  PATHS_TO_SYNC="${normalized_paths_to_sync[@]}"
  log_info "Complete list of paths to sync: $PATHS_TO_SYNC"
}

#
# Usage: configure_excludes IGNORE_FILE [EXCLUDE_PATHS_FROM_CMD_LINE ...]
#
# Sets the paths that should be excluded when syncing files to the Boot2Docker
# VM. EXCLUDE_PATHS_FROM_CMD_LINE are paths specified as command line arguments
# and will take precedence. If none are specified, this function will try to
# read the ignore file (see load_exclude_paths) at IGNORE_FILE and use those
# entries as excludes. If that fails, this function will fall back to
# DEFAULT_EXCLUDES.
#
function configure_excludes {
  local readonly ignore_file="$1"
  shift
  local readonly excludes_from_cmd_line=("$@")
  local readonly excludes_from_ignore_file=($(load_exclude_paths "$ignore_file"))

  local excludes=()
  if [[ "${#excludes_from_cmd_line}" -gt 0 ]]; then
    log_info "Using exclude paths from command line args: ${excludes_from_cmd_line[@]}"
    excludes+=("${excludes_from_cmd_line[@]}")
  fi

  if [[ "${#excludes_from_ignore_file}" -gt 0 ]]; then
    log_info "Using excludes from ignore file $ignore_file: ${excludes_from_ignore_file[@]}"
    excludes+=("${excludes_from_ignore_file[@]}")
  fi

  if [[ "${#excludes[@]}" -eq 0 ]]; then
    log_info "Using default exclude paths: $DEFAULT_EXCLUDES"
    excludes=($DEFAULT_EXCLUDES)
  fi

  EXCLUDES="${excludes[@]}"
  log_info "Complete list of paths to exclude: $EXCLUDES"
}

#
# Usage: configure_includes IGNORE_FILE [INCLUDE_PATHS_FROM_CMD_LINE ...]
#
# Sets the paths that should be included when syncing files to the Boot2Docker
# VM. INCLUDE_PATHS_FROM_CMD_LINE are paths specified as command line arguments
# and will take precedence. If none are specified, this function will try to
# read the ignore file (see load_include_paths) at IGNORE_FILE and use those
# entries as includes.
#
function configure_includes {
  local readonly ignore_file="$1"
  shift
  local readonly includes_from_cmd_line=("$@")
  local readonly includes_from_ignore_file=($(load_include_paths "$ignore_file"))

  local includes=()
  if [[ "${#includes_from_cmd_line}" -gt 0 ]]; then
    log_info "Using include paths from command line args: ${includes_from_cmd_line[@]}"
    includes+=("${includes_from_cmd_line[@]}")
  fi

  if [[ "${#includes_from_ignore_file}" -gt 0 ]]; then
    log_info "Using includes from ignore file $ignore_file: ${includes_from_ignore_file[@]}"
    includes+=("${includes_from_ignore_file[@]}")
  fi

  INCLUDES="${includes[@]}"
  log_info "Complete list of paths to include: $INCLUDES"
}

#
# Usage: sync REMOVE_SHARED_FOLDERS
#
# Runs the docker-osx-dev script to to sync files.
#
function sync {
  local readonly remove_shared_folders=$1

  log_info "Starting docker-osx-dev file syncing"
  init_docker_host "$remove_shared_folders"
  install_rsync_on_docker_host
  initial_sync
}

#
# Usage: install [SKIP_DEPENDENCIES] [ONLY_DEPENDENCIES] [REMOVE_SHARED_FOLDERS]
#
# Installs the docker-osx-dev script, all of its dependencies, and configures
# the environment.
#
function install {
  log_info "Starting install of docker-osx-dev"
  local readonly skip_dependencies=$1
  local readonly only_dependencies=$2
  local readonly remove_shared_folders=$3
  if test -z "$skip_dependencies" ; then
    install_dependencies
  fi
  if test -z "$only_dependencies" ; then
    init_docker_host "$remove_shared_folders"
    install_rsync_on_docker_host
    add_docker_host
    add_environment_variables
    print_next_steps
  fi
}

#
# Executes no code or side effects. Used only at test time to make it easy to
# "source" this script.
#
function test_mode {
  return 0
}

#
# Usage: assert_valid_arg ARG ARG_NAME
#
# Asserts that ARG is not empty and is not a flag (i.e. starts with a - or --)
#
# Examples:
#
# assert_valid_arg "foo" "--my-arg"
#   returns 0
#
# assert_valid_arg "" "--my-arg"
#   prints error, instructions, and exits with error code 1
#
# assert_valid_arg "--foo" "--my-arg"
#   prints error, instructions, and exits with error code 1
#
function assert_valid_arg {
  local readonly arg="$1"
  local readonly arg_name="$2"

  if [[ -z "$arg" || "${arg:0:1}" = "-" ]]; then
    log_error "You must provide a value for argument $arg_name"
    instructions
    exit 1
  fi
}

#
# Usage handle_command ARGS ...
#
# Parses ARGS to kick off this script. See the output of the instructions
# function for details.
#
function handle_command {
  check_prerequisites

  local cmd="$DEFAULT_COMMAND"
  local log_level="$DEFAULT_LOG_LEVEL"
  local docker_compose_file="$DEFAULT_COMPOSE_FILE"
  local ignore_file="$DEFAULT_IGNORE_FILE"
  local paths_to_sync=()
  local excludes=()
  local includes=()

  while [[ $# > 0 ]]; do
    key="$1"

    case $key in
      "$SYNC_COMMAND")
        cmd="$SYNC_COMMAND"
        ;;
      "$SYNC_ONLY_COMMAND")
        cmd="$SYNC_ONLY_COMMAND"
        ;;
      "$WATCH_ONLY_COMMAND")
        cmd="$WATCH_ONLY_COMMAND"
        ;;
      "$INSTALL_COMMAND")
        cmd="$INSTALL_COMMAND"
        ;;
      "$TEST_COMMAND")
        cmd="$TEST_COMMAND"
        ;;
      -s|--sync-path)
        assert_valid_arg "$2" "$key"
        paths_to_sync+=("$2")
        shift
        ;;
      -e|--exclude-path)
        assert_valid_arg "$2" "$key"
        excludes+=("$2")
        shift
        ;;
      -I|--include-path)
        assert_valid_arg "$2" "$key"
        includes+=("$2")
        shift
        ;;
      -c|--compose-file)
        assert_valid_arg "$2" "$key"
        docker_compose_file="$2"
        shift
        ;;
      -i|--ignore-file)
        assert_valid_arg "$2" "$key"
        ignore_file="$2"
        shift
        ;;
      -l|--log-level)
        assert_valid_arg "$2" "$key"
        log_level="$2"
        shift
        ;;
      -m|--machine-name)
        assert_valid_arg "$2" "$key"
        DOCKER_MACHINE_NAME="$2"
        shift
        ;;
      -r|--remove-shared-folders)
        local readonly remove_shared_folders=true
        ;;
      --only-dependencies)
        local readonly only_dependencies=true
        ;;
      --skip-dependencies)
        local readonly skip_dependencies=true
        ;;
      -h|--help)
        instructions
        exit 0
        ;;
      *)
        log_error "Unrecognized argument: $key"
        instructions
        exit 1
        ;;
    esac

    shift
  done

  assert_mutually_exclusive "--only-dependencies and --skip-dependencies are mutually exclusive" "$only_dependencies" "$skip_dependencies"

  case "$cmd" in
    "$SYNC_COMMAND" | "$SYNC_ONLY_COMMAND" | "$WATCH_ONLY_COMMAND")
      configure_log_level "$log_level"
      configure_paths_to_sync "$docker_compose_file" "${paths_to_sync[@]}"
      configure_excludes "$ignore_file" "${excludes[@]}"
      configure_includes "$ignore_file" "${includes[@]}"
      case "$cmd" in
      "$SYNC_COMMAND")
        sync "$remove_shared_folders"
        watch
        ;;
      "$SYNC_ONLY_COMMAND")
        sync "$remove_shared_folders"
        ;;
      "$WATCH_ONLY_COMMAND")
        watch
        ;;
      esac
      ;;
    "$INSTALL_COMMAND")
      configure_log_level "$log_level"
      install "$skip_dependencies" "$only_dependencies" "$remove_shared_folders"
      ;;
    "$TEST_COMMAND")
      test_mode
      ;;
    *)
      log_error "Internal error: unrecognized command $cmd"
      exit 1
      ;;
  esac
}

handle_command "$@"
